feat(credits): credit ledger, subscription plans, and a metered nodetool provider - #4760
Conversation
Server-owned credits and plans for the Studio product: - nodetool_credit_ledger + nodetool_user_subscriptions tables (migration 20260806_000000, both dialects, test-DB DDL). The ledger stores grants only; balance = sum(grants) - ceil(prediction spend / 1 cent), so spend is never double-booked. - Plan catalog (Free 300/mo, Creator 3000/mo, Pro 10000/mo) with lazy, idempotent monthly accrual keyed plan:<user>:<plan>:<YYYY-MM> — no cron. - trpc.credits.status/setPlan/topup (schemas in protocol). Top-up is an explicit no-payment stub; a payment provider replaces it later. - Spend gate (credit-gate.ts), off by default: with NODETOOL_CREDITS_ENFORCED=1 workflow runs are refused on an empty balance (BUDGET_EXCEEDED, next to the application-budget gate) and the direct generate_media / transcribe_audio RPCs throw. Fails open on gate errors like the app gate. - Studio UI: server-backed credits chip linking to /studio/account — balance, usage, plan cards, test top-up. - Tests: credits model (7), credit gate (3), migration count bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
Replace the deployment-wide NODETOOL_CREDITS_ENFORCED switch with a real provider: NodeTool's own managed models. - New provider id "nodetool" (PROVIDER_IDS) with a curated catalog in protocol (NODETOOL_MODELS): each model names a delegate provider+model. NodetoolProvider runs delegates on platform-owned keys (NODETOOL_PLATFORM_FAL_KEY / NODETOOL_PLATFORM_ANTHROPIC_KEY), absorbs the delegate's cost as its own, and lists only funded models — so the provider appears in pickers exactly when the server can serve it. - model-pricing translates nodetool/... ids to the delegate before catalog lookup, so estimates for metered spend are real numbers. - Credit gate now follows the provider, not the deployment: workflow runs are gated on the nodetool slice of their cost estimate only (estimateNodetoolSpend), direct generate_media/transcribe_audio RPCs are gated only when called with provider "nodetool", and BYOK providers are never gated. The env var is gone; credits and bring-your-own-key coexist per call on one server. - credits.status now reports meteredProvider instead of enforced; Studio's curated models point at the nodetool provider, and the account page copy explains the split. - Tests: NodetoolProvider (funded filtering, delegation errors), pricing translation, gate without the env var. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
The provider contract suite requires every registered provider to ship a chat cassette or a justified exemption. NodetoolProvider makes no wire calls of its own — chat routes to the delegate named in NODETOOL_MODELS, whose own cassette covers the contract — and an id outside the curated catalog rejects cleanly, which the exemption test asserts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
georgi
left a comment
There was a problem hiding this comment.
I found five accounting and enforcement issues that should be addressed before this is used with platform-owned provider keys. Inline comments include concrete failure modes and suggested fixes.
| const spendRows = await db | ||
| .select({ total: sql<number>`COALESCE(SUM(${predictions.cost}), 0)` }) | ||
| .from(predictions) | ||
| .where(eq(predictions.user_id, userId)); |
There was a problem hiding this comment.
This aggregation needs to be scoped to provider = "nodetool". As written, every prediction for the user, including fal_ai, anthropic, and other BYOK calls, is deducted from the managed credit balance. Existing users with historical BYOK spend can therefore start at zero, and future BYOK calls continue draining credits, contradicting the provider-scoped contract. Please add the provider predicate and a regression test with both managed and BYOK predictions.
| .input(topupInput) | ||
| .output(creditStatusOutput) | ||
| .mutation(async ({ ctx, input }) => { | ||
| await grantCredits( |
There was a problem hiding this comment.
This lets any authenticated caller mint up to 50,000 credits per request, repeatedly. Because those credits unlock calls made with platform-owned keys, the RPC makes managed provider spend effectively unlimited; labeling the UI button as a test does not protect the endpoint. Please remove or gate this behind an explicit development-only configuration, or make grants admin/payment-webhook-only before exposing the router.
| // user's own keys. Direct generation has no per-node estimate, so the | ||
| // gate refuses on an empty balance. | ||
| if (req.provider === "nodetool") { | ||
| const creditDecision = await admitSpend(userId, 0); |
There was a problem hiding this comment.
A zero-cost check only verifies that the balance is currently positive. This direct RPC does not write a Prediction, so a user who has received the free grant can call generate_media indefinitely through the platform key, including the variations fan-out, without ever reducing the balance. The same issue exists in transcribe_audio. Please reserve and record/settle the actual provider cost for these paths, or disable the managed provider here until that accounting exists.
| async runJob(req: RunJobRequest): Promise<void> { | ||
| req._accepted_at_ms ??= performance.now(); | ||
| if (!(await this.admitApplicationRun(req))) return; | ||
| if (!(await this.admitCreditRun(req))) return; |
There was a problem hiding this comment.
This check happens before concurrency queueing and does not reserve credits. Multiple submissions can all pass against the same balance, then queued jobs execute later without another check after earlier jobs have spent it. A small balance can therefore authorize substantially more platform spend than it contains. Please atomically reserve estimated credits per accepted run and settle against actual cost, or at minimum recheck/reserve when a queued run is dequeued, following the application-budget pattern.
| try { | ||
| return await call(); | ||
| } finally { | ||
| this._absorbedCost += inner.getTotalCost() - before; |
There was a problem hiding this comment.
The cached inner provider can serve overlapping calls, so measuring a call via the shared cumulative getTotalCost() is not concurrency-safe. If A and B both read 0, A adds x, then B finishes at x + y, the outer provider absorbs x and then x + y, double-counting A. generateMessages has the same pattern. Please use request-scoped cost/usage, a fresh delegate per concurrent call, or serialize accounting around each shared delegate.
- Scope the balance's spend aggregation to provider "nodetool": BYOK predictions (fal_ai, anthropic, ...) no longer drain credits, and historical BYOK spend no longer zeroes new balances. Regression test covers mixed managed/BYOK rows. - Gate the no-payment top-up behind NODETOOL_ENABLE_TEST_TOPUP (off by default): the mutation throws FORBIDDEN otherwise, status reports testTopupEnabled, and the UI hides the button. - Direct generate_media now admits against a unit-price estimate, reserves it for the duration of the call, and records a prediction row (max of delegate-tracked cost and the unit estimate) so the balance decrements; transcribe_audio records delegate-tracked cost likewise. - Workflow runs reserve their nodetool estimate when admitted and release at the terminal state or cancel-while-queued, so concurrent or queued submissions can't multiply a small balance (process-local, TTL- bounded; a multi-instance deployment moves this into the DB like application-budgets). - NodetoolProvider builds a fresh delegate per call instead of sharing a cached instance, so absorbing cost off the delegate's cumulative counter can't double-count overlapping calls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
|
All five findings addressed in ddfd5fb:
Generated by Claude Code |
What
Credit management and subscription plans for the Studio product (follow-up to #4758), with metering modeled as a provider, not a deployment switch: NodeTool's own managed models are a real provider (
nodetool) whose spend is metered against the user's credit balance, while BYOK providers coexist unmetered on the same server.Design
nodetool_credit_ledgerstores grants only (plan accruals, top-ups, adjustments). Spend is read from thenodetool_predictionsrows every provider call already writes, at 1 credit = $0.01 — no double bookkeeping.nodetool_user_subscriptionsholds one row per user (Free 300/mo, Creator 3,000/mo $12, Pro 10,000/mo $40). Monthly grants accrue lazily on the first status read of the month, keyedplan:<user>:<plan>:<YYYY-MM>so the primary key makes double accrual impossible.nodetoolprovider is what gets metered. Each curated model (NODETOOL_MODELSin protocol) names a delegate provider+model;NodetoolProviderruns the delegate on platform-owned keys (NODETOOL_PLATFORM_FAL_KEY,NODETOOL_PLATFORM_ANTHROPIC_KEY), absorbs the delegate's cost at the delegate's price, and lists only funded models — the provider appears in pickers exactly when the server can serve it.model-pricingtranslatesnodetool/...ids to the delegate before catalog lookup, so estimates are real numbers.nodetoolslice of their cost estimate (estimateNodetoolSpend→admitCreditRun, beside the existing application-budget gate); the directgenerate_media/transcribe_audioRPCs are gated only when called withprovider: "nodetool". BYOK is never gated. The gate fails open on its own errors, like the app-budget gate.credits.topupadds credits with no payment behind it and the UI says so; a payment provider integration replaces that mutation with a checkout session writing the same ledger rows from its webhook.Changes
packages/protocol:PROVIDER_IDS.NODETOOL, curated catalognodetool-models.ts,api-schemas/credits.ts(status reportsmeteredProvider), cloud-profile inclusion.packages/runtime:providers/nodetool-provider.ts(delegating provider, cost absorption, funded-model listing) + registration.packages/model-pricing: nodetool→delegate id translation ingetModelUnitPrice.packages/models:credits.ts(catalog, subscription, accrual, balance,checkCredits), schema + pg mirror, migration20260806_000000, test-DB DDL.packages/websocket:credit-gate.ts,trpc/routers/credits.ts, provider-scoped gating inunified-websocket-runner.ts.web/src/studio: curated models point at thenodetoolprovider; server-backed credits chip;/studio/accountpage (balance, usage, plan cards, test top-up); provider display name.docs/agentic-video-product.mdrewritten to match.Verification
modelssuite green (814); websocket suite green (2,211); web typecheck + eslint green.NODETOOL_PLATFORM_FAL_KEYset,models.imageByProvider/videoByProviderfornodetoolreturn exactly the funded curated models stampedprovider: "nodetool"; the anthropic-delegated director is absent without its key./studio/accountrenders; Free→Creator switch moves the balance 300 → 3,300 instantly.🤖 Generated with Claude Code
https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2